17장. 제네릭
16장에서 인터페이스로 다양한 타입을 받는 법을 배웠다.
그중 빈 인터페이스 any 는
모든 타입을 받을 수 있었지만
타입 정보를 잃어버린다 는 약점이 있었다.
이 장에서는 그 약점을 메우는 도구를 다룬다. Go 1.18 부터 추가된 제네릭(generics)이다.
제네릭은 “타입을 매개변수처럼 받는” 함수와 타입을 만드는 기능이다.
목표:
- 제네릭이 필요한 이유 이해
- 타입 매개변수 문법 익히기
- 타입 제약(constraints) 사용
Map,Filter같은 함수 직접 구현- 제네릭 타입 맛보기
- 언제 쓰고 언제 안 쓸지 판단
17.1 제네릭이 왜 필요한가
같은 함수를 타입마다 복사하는 문제
정수 슬라이스의 합을 구하는 함수를 짜 보자.
func SumInt(nums []int) int {
var total int
for _, n := range nums {
total += n
}
return total
}
float64 슬라이스에도 같은 게 필요해졌다.
func SumFloat(nums []float64) float64 {
var total float64
for _, n := range nums {
total += n
}
return total
}
본문은 거의 똑같다. 타입만 다르다. 타입이 늘어날수록 같은 함수가 계속 늘어난다.
any 로 해결하면?
func SumAny(nums []any) any {
// 안에서 일일이 타입 단언이 필요하다
// 컴파일러가 타입 검사를 해 주지도 않는다
}
any 로 받으면 코드는 짧아지지만 단점이 크다.
- 호출하는 쪽에서
[]any를 만들어 줘야 한다 - 함수 안에서 매번 타입 단언이 필요하다
- 컴파일 시점에 타입 오류를 못 잡는다
- 박싱 비용 (값을 인터페이스에 감싸는 비용)
제네릭이 답이다
제네릭을 쓰면 함수 하나로 끝난다.
func Sum[T int | float64](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
Sum([]int{1, 2, 3}) // 6
Sum([]float64{1.5, 2.5}) // 4.0
- 함수는 단 하나
- 컴파일러가 타입을 검사
- 호출하는 쪽도 평소처럼 자연스럽다
17.2 타입 매개변수 문법
제네릭의 핵심은 함수 이름과 매개변수 목록 사이에 들어가는 대괄호 블록 이다.
func 이름[T any](인자 T) T { ... }
// ^^^^^^^
// 타입 매개변수
가장 단순한 예
func PrintAnything[T any](v T) {
fmt.Println(v)
}
PrintAnything[int](42)
PrintAnything[string]("hi")
호출 시 대괄호로 타입을 지정한다. 하지만 보통 생략한다. Go 컴파일러가 인자에서 자동 추론한다.
PrintAnything(42) // T = int 추론됨
PrintAnything("hi") // T = string 추론됨
여러 타입 매개변수
쉼표로 나열한다.
func Pair[K, V any](k K, v V) {
fmt.Println(k, v)
}
Pair("age", 30)
Go 1.18 이상 필요
제네릭은 Go 1.18 (2022년 3월) 부터 추가됐다. 설치된 Go 버전이 이보다 낮다면 동작하지 않는다.
go version
go1.18 이상이어야 한다. 현재는 1.22 이상이 일반적.
17.3 타입 제약 (Constraints)
타입 매개변수에는 항상 “어떤 타입이어야 하는가” 라는 조건을 붙여야 한다. 이게 타입 제약(constraint) 이다.
any: 아무 타입이나
func Identity[T any](x T) T {
return x
}
any 는 16장에서 본 빈 인터페이스다.
모든 타입을 허용한다.
comparable: == 가 되는 타입
func Equal[T comparable](a, b T) bool {
return a == b
}
Equal(1, 1) // true
Equal("a", "b") // false
Equal([]int{}, ...) // 컴파일 에러 (슬라이스는 == 불가)
comparable 은 Go 가 내장한 특수 제약이다.
정수, 문자열, 포인터 등 == 가 되는 타입만 받는다.
타입 집합으로 제한
| 로 여러 타입을 묶을 수 있다.
func Sum[T int | float64](nums []T) T { ... }
Sum 은 int 슬라이스나 float64 슬라이스만 받는다.
인터페이스를 제약으로
타입 집합이 길어지면 인터페이스로 빼는 게 깔끔하다.
type Number interface {
int | int64 | float32 | float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
Number 는 메서드를 요구하지 않고
타입의 목록만 나열 한다.
이렇게도 인터페이스를 정의할 수 있다 (Go 1.18 부터).
~T (underlying type 포함)
다음 코드는 컴파일이 안 된다.
type MyInt int
var nums []MyInt
Sum(nums) // 에러
MyInt 는 int 와 다른 타입이라 int | float64 에 안 맞는다.
이때 ~ 를 붙이면 “그 타입을 기반으로 한 모든 타입” 으로 확장된다.
type Number interface {
~int | ~int64 | ~float32 | ~float64
}
~int 는 “int 그 자체이거나
underlying type 이 int 인 모든 타입” 이다.
이제 MyInt 도 통과한다.
~가 있으면 사용자 정의 타입까지 받는다. 라이브러리에서는 거의 항상~를 붙인다.
17.4 제네릭 함수 예제
다른 언어의 함수형 도구를 직접 구현해 보자.
Map: 모든 원소에 함수 적용
func Map[T, R any](s []T, f func(T) R) []R {
result := make([]R, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
사용 예:
nums := []int{1, 2, 3, 4}
doubled := Map(nums, func(n int) int { return n * 2 })
// [2 4 6 8]
asStr := Map(nums, func(n int) string {
return fmt.Sprintf("v%d", n)
})
// ["v1" "v2" "v3" "v4"]
T 는 입력 슬라이스의 타입,
R 은 결과 슬라이스의 타입이다.
Filter: 조건에 맞는 원소만
func Filter[T any](s []T, pred func(T) bool) []T {
result := make([]T, 0, len(s))
for _, v := range s {
if pred(v) {
result = append(result, v)
}
}
return result
}
사용 예:
nums := []int{1, 2, 3, 4, 5, 6}
evens := Filter(nums, func(n int) bool {
return n%2 == 0
})
// [2 4 6]
Reduce: 모든 원소를 한 값으로 접기
func Reduce[T, R any](s []T, init R, f func(R, T) R) R {
acc := init
for _, v := range s {
acc = f(acc, v)
}
return acc
}
사용 예:
nums := []int{1, 2, 3, 4, 5}
sum := Reduce(nums, 0, func(acc, n int) int {
return acc + n
})
// 15
joined := Reduce([]string{"가", "나", "다"}, "",
func(acc, s string) string { return acc + s })
// "가나다"
이 세 함수는
golang.org/x/exp/slices,golang.org/x/exp/maps같은 실험 패키지에서 비슷한 형태로 제공된다.
17.5 제네릭 타입 맛보기
함수만 제네릭이 되는 게 아니다. 타입 자체도 제네릭으로 정의할 수 있다.
제네릭 스택
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
last := len(s.items) - 1
v := s.items[last]
s.items = s.items[:last]
return v, true
}
func (s *Stack[T]) Len() int {
return len(s.items)
}
세 가지 새로운 문법이 있다.
| 문법 | 의미 |
|---|---|
type Stack[T any] struct | 타입 매개변수 T 를 가진 구조체 |
func (s *Stack[T]) Push(v T) | 메서드에서 T 그대로 사용 |
var zero T | T 타입의 제로값 |
사용
func main() {
intStack := &Stack[int]{}
intStack.Push(1)
intStack.Push(2)
v, _ := intStack.Pop()
fmt.Println(v) // 2
strStack := &Stack[string]{}
strStack.Push("hello")
}
타입 매개변수를 인스턴스 만들 때 명시한다.
Stack[int], Stack[string] 처럼.
18장에서 다룰
container/list같은 표준 자료구조는 제네릭이 도입되기 전에 만들어졌다. 그래서any기반이고, 사용 시 타입 단언이 필요하다.
17.6 언제 쓰고 언제 안 쓰는가
제네릭은 강력하지만 만능 망치가 아니다.
잘 어울리는 경우
- 컬렉션 자료구조
- 스택, 큐, 트리, 집합 등
- 안에 들어가는 타입과 무관한 로직
- 알고리즘
- 정렬, 검색, Map / Filter / Reduce
- 비교만 되면 동작하는 함수
- 유틸리티 함수
Min,Max,Contains같은 류
어울리지 않는 경우
- 단일 타입에서만 쓰는 코드
- 미리 한 가지 타입으로 잡혀 있다면 제네릭을 끌어들이는 게 오히려 복잡해진다
- 인터페이스로 충분한 경우
- 메서드 호출이 핵심이라면 인터페이스가 더 자연스럽다
비교해 보자.
// 인터페이스 — 메서드를 호출하면 충분할 때
type Speaker interface {
Speak() string
}
func Announce(s Speaker) { fmt.Println(s.Speak()) }
// 제네릭 — 타입 자체를 들고 다녀야 할 때
func First[T any](s []T) (T, bool) {
var zero T
if len(s) == 0 { return zero, false }
return s[0], true
}
판단 기준 한 줄 요약
| 조건 | 권장 |
|---|---|
| 다양한 타입의 컨테이너 / 알고리즘 | 제네릭 |
| 다양한 타입의 “동작” 호출 | 인터페이스 |
| 한 타입에서만 쓰는 코드 | 일반 함수 |
| 박싱 비용이 신경 쓰임 | 제네릭 (any 대신) |
“인터페이스로 풀리면 인터페이스를 먼저 써 본다. 그래도 부족하면 제네릭을 꺼낸다.” 가 일반적인 가이드라인이다.
17.7 정리
이 장에서 살펴본 내용:
- 제네릭은 같은 함수를 타입마다 복사하는 문제를 해결한다
any보다 타입 안정성이 높고 박싱 비용도 적다- 문법은
[T 제약]형태로 함수 / 타입에 붙인다 - 타입 제약:
any,comparable, 타입 집합, 인터페이스 ~T로 사용자 정의 타입까지 포함시킬 수 있다Map,Filter,Reduce같은 도구를 직접 만들 수 있다- 제네릭 타입 (
Stack[T any]) 으로 재사용 가능한 자료구조 작성 - 컬렉션 / 알고리즘에 어울리고, 단일 타입 코드에는 굳이 쓸 필요가 없다
여기까지가 Go 의 추상화 도구 한 묶음이다.
- 14장 포인터 — “참조” 의 도입
- 15장 메서드 — 타입에 동작 묶기
- 16장 인터페이스 — 동작으로 묶어 다루기
- 17장 제네릭 — 타입으로 묶어 다루기
이 네 가지가 함께 작동하면 대부분의 모델링 문제를 풀 수 있다.
7부에서는 다시 자료구조로 돌아간다. 지금까지 배운 도구들을 활용해 리스트 / 큐 / 스택 / 맵을 더 깊이 들여다본다.